package com.maun.maun;

import com.maun.exception.MarshalerException;
import com.maun.exception.UnknownDataTypeException;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Properties;
import java.util.logging.Level;
import java.util.logging.Logger;

public final class MaunMySql implements Maun {

    ////////////////////////////////////////////////////////////////////////////////////////////////
    // Static

    private final static Logger LOGGER = Logger.getLogger(MaunMySql.class.getName());

    ////////////////////////////////////////////////////////////////////////////////////////////////
    // Instance

    private Connection connection = null;
    
    public MaunMySql(
            final Properties properties) throws MarshalerException {

        getConnection(properties);
    }

    @Override
    public String createSchema(
            final Class clazz) throws MarshalerException {

        final StringBuilder createTableQuery = new StringBuilder(
                "create table " + getTableName(clazz) + "(");

        final List<Field> accessibleFields = getAccessibleFields(clazz);
        for (int i = 0; i < accessibleFields.size(); i++) {
            final Field field = accessibleFields.get(i);
            final SqlDataType sqlType = matchSqlType(field);

            createTableQuery
                    .append(field.getName())
                    .append(" ")
                    .append(sqlType.name().toLowerCase())
                    .append(defineMaxFieldSize(field, sqlType));

            if (i != accessibleFields.size() - 1) {
                createTableQuery.append(",");
            }
        }

        createTableQuery.append(")engine=InnoDB;");

        return createTableQuery.toString();
    }
    
    
    @Override
    public void initSchema(
            final Class clazz) throws MarshalerException {

        final String createTableQuery = createSchema(clazz);

        Statement statement = null;
        try {
            statement = connection.createStatement();

            int check = statement.executeUpdate(createTableQuery);
            if (check != 0) {
                throw new MarshalerException(
                        "Failed to initialize the schema. "
                      + "The number of affected rows returned by MySQL is '" + check + "'.");
            }
        } catch (SQLException e) {
            throw new MarshalerException(
                    "Failed to initialize the schema. "
                  + "Check if the connection to the persistent store is valid.", e);
        } finally {
            checkAndCloseStatement(statement);
        }
    }

    @Override
    public String marshal(
            final Object object) throws MarshalerException {

        final PreparedStatement preparedStatement = createPreparedStatement(object);
        final String psToString = preparedStatement.toString();

        checkAndCloseStatement(preparedStatement);

        final String marshaledObject;
        if (psToString.indexOf(":") != -1) {
            marshaledObject = psToString.split(":")[1].trim();
        } else {
            marshaledObject = psToString;
        }
        
        return marshaledObject;
    }

    @Override
    public void save(
            final Object object) throws MarshalerException {

        PreparedStatement preparedStatement = null;
        try {
            preparedStatement = createPreparedStatement(object);

            int check = preparedStatement.executeUpdate();
            if (check != 1) {
                throw new MarshalerException(
                        "Failed to to save the object. "
                      + "The number of affected rows returned by MySQL is '" + check + "'.");
            }
        } catch (SQLException e) {
            throw new MarshalerException(
                    "Failed to execute the SQL statement to save the object. "
                  + "Check if the schema for marshaling was initialized "
                  + "and the connection to the persistent store is valid.", e);
        } finally {
            checkAndCloseStatement(preparedStatement);
        }
    }

    @Override
    public void close(
            ) {

        if (connection != null) {
            try {
                connection.close();
            } catch (SQLException e) {
                LOGGER.log(Level.WARNING, 
                        "Failed to close the connection to the persistent store.", e);
            }
        }
    }

    // Private -------------------------------------------------------------------------------------

    private Connection getConnection(
            final Properties properties) throws MarshalerException {

        if (connection == null) {
            try {
                connection = DriverManager.getConnection(
                        "jdbc:mysql://"
                      + properties.getProperty("mysql.host")
                      + ":" + properties.getProperty("mysql.port")
                      + "/" + properties.getProperty("mysql.database"),
                        properties.getProperty("mysql.username"),
                        properties.getProperty("mysql.password"));

            } catch (SQLException e) {
                throw new MarshalerException(
                        "Initialization error, failed to connect to the persistent store. "
                      + "Check the connection properties.", e);
            }
        } else {
            try {
                if (!connection.isValid(5)) {
                    connection = null;
                    connection = getConnection(properties);
                }
            } catch (SQLException e) {
                throw new MarshalerException(
                        "Initialization error, failed to connect to the persistent store. "
                      + "Check te connection properties.", e);
            }
        }

        return connection;
    }

    private PreparedStatement createPreparedStatement(
            final Object object) throws MarshalerException {

        final String tableName = getTableName(object.getClass());
        final List<Field> accessibleFields = getAccessibleFields(object.getClass());

        final StringBuilder tableFieldNames = new StringBuilder();
        final StringBuilder tableFieldValues = new StringBuilder();
        for (int i = 0; i < accessibleFields.size(); i++) {
            tableFieldNames.append(accessibleFields.get(i).getName());
            tableFieldValues.append("?");

            if (i != accessibleFields.size() - 1) {
                tableFieldNames.append(",");
                tableFieldValues.append(",");
            }
        }

        final StringBuilder query = new StringBuilder("insert into " + tableName + "(");
        query.append(tableFieldNames.toString());
        query.append(") values (");
        query.append(tableFieldValues.toString());
        query.append(");");

        PreparedStatement preparedStatement = null;
        try {
            preparedStatement = connection.prepareStatement(query.toString());
        } catch (SQLException e) {
            throw new MarshalerException(
                    "Failed to initialize the SQL statement for marshaling an "
                  + "object of the class '" + object.getClass().getName() + "'.", e);
        }

        for (int i = 0; i < accessibleFields.size(); i++) {
            final Field field = accessibleFields.get(i);
            setTableFieldValue(i + 1, field, preparedStatement, object);
        }

        return preparedStatement;
    }

    private String getTableName(
            final Class clazz) {

        return clazz.getSimpleName();
    }

    private List<Field> getAllClassFields(
            final Class clazz) {

        final List<Field> fields =
                new ArrayList<Field>(Arrays.asList(clazz.getDeclaredFields()));

        if (clazz.getSuperclass() != null) {
            fields.addAll(getAllClassFields(clazz.getSuperclass()));
        }

        return fields;
    }

    private List<Method> getAllClassMethods(
            final Class clazz) {

        final List<Method> methods =
                new ArrayList<Method>(Arrays.asList(clazz.getDeclaredMethods()));

        if (clazz.getSuperclass() != null) {
            methods.addAll(getAllClassMethods(clazz.getSuperclass()));
        }
        return methods;
    }

    private List<Field> getAccessibleFields(
            final Class clazz) {

        final List<Field> allFields = getAllClassFields(clazz);
        final List<Method> allMethods = getAllClassMethods(clazz);

        final List<Field> fieldsWithGetterAndSetter =
                getFieldsWithGetterAndSetter(allFields, allMethods);

        final List<Method> getMethods = new ArrayList<Method>();
        final List<Method> setMethods = new ArrayList<Method>();
        for (Field field : fieldsWithGetterAndSetter) {
            for (Method method : allMethods) {
                final String methodName = method.getName();

                if (methodName.equals(defineSetMethodName(field))) {
                    setMethods.add(method);
                } else if (methodName.equals(defineGetMethodName(field))
                        || methodName.equals(defineIsMethodName(field))) {

                    getMethods.add(method);
                }
            }
        }

        final List <Field> fieldsWithCorrectSetter =
                new ArrayList<Field>(checkSetMethods(fieldsWithGetterAndSetter, setMethods));
        final List <Field> fieldsWithCorrectGetter =
                new ArrayList<Field>(checkGetMethods(fieldsWithGetterAndSetter, getMethods));
        
        final List<Field> accessibleFields = new ArrayList<Field>();
        for (Field field : fieldsWithCorrectSetter) {
            if (fieldsWithCorrectGetter.contains(field)){
                accessibleFields.add(field);
            }
        }

        return accessibleFields;
    }

    private List<Field> getFieldsWithGetterAndSetter(
                final List<Field> allFields,
                final List<Method> allMethods) {

        final List<String> allMethodsNames = new ArrayList<String>();
        for (Method method : allMethods) {
            allMethodsNames.add(method.getName());
        }

        final List<Field> fieldsWithGetterAndSetter = new ArrayList<Field>();
        for (Field field : allFields) {
            if (allMethodsNames.contains(defineSetMethodName(field))) {
                if (allMethodsNames.contains(defineGetMethodName(field))
                        || allMethodsNames.contains(defineIsMethodName(field))) {

                    fieldsWithGetterAndSetter.add(field);
                }
            }
        }

        return fieldsWithGetterAndSetter;
    }

    private List<Field> checkSetMethods(
            final List<Field> fields,
            final List<Method> methods) {

        final List<Field> accessibleFields = new ArrayList<Field>();
        for (Field field : fields) {
            final String setMethodName = defineSetMethodName(field);

            for (Method method : methods) {
                if (setMethodName.equals(method.getName())
                        && Modifier.isPublic(method.getModifiers())
                        && (method.getGenericParameterTypes().length == 1)
                        && method.getGenericParameterTypes()[0].equals(field.getGenericType())) {

                    accessibleFields.add(field);
                    break;
                }
            }
        }

        return accessibleFields;
    }

    private List<Field> checkGetMethods(
            final List<Field> fields,
            final List<Method> methods) {

        final List<Field> accessibleFields = new ArrayList<Field>();
        for (Field field : fields) {
            final String getMethodName = defineGetMethodName(field);
            final String isMethodName = defineIsMethodName(field);

            for (Method method : methods) {
                final String methodName = method.getName();

                if ((getMethodName.equals(methodName) || isMethodName.equals(methodName))
                        && Modifier.isPublic(method.getModifiers())
                        && (method.getGenericParameterTypes().length == 0)
                        && method.getGenericReturnType().equals(field.getGenericType())) {

                    accessibleFields.add(field);
                    break;
                }
            }
        }

        return accessibleFields;
    }
    
    private String defineSetMethodName(
            final Field field) {

        final String fieldName = field.getName();
        final String fieldNameUpCase =
                fieldName.substring(0, 1).toUpperCase() + fieldName.substring(1);

        return "set" + fieldNameUpCase;
    }
    
    private String defineGetMethodName(
            final Field field) {
        
        final String fieldName = field.getName();
        final String fieldNameUpCase =
                fieldName.substring(0, 1).toUpperCase() + fieldName.substring(1);

        return "get" + fieldNameUpCase;
    }
     
    private String defineIsMethodName(
            final Field field) {

        final String fieldName = field.getName();
        final String fieldNameUpCase =
                fieldName.substring(0, 1).toUpperCase() + fieldName.substring(1);

        return "is" + fieldNameUpCase;
    }
    
    private SqlDataType matchSqlType(
            final Field field) throws MarshalerException {

        SqlDataType sqlDataType;

        if (field.getType().equals(byte.class) || field.getType().equals(boolean.class)) {
            sqlDataType = SqlDataType.TINYINT;
        } else

        if (field.getType().equals(short.class)) {
            sqlDataType = SqlDataType.SMALLINT;
        } else

        if (field.getType().equals(int.class)) {
            sqlDataType = SqlDataType.INT;
        } else

        if (field.getType().equals(float.class)) {
            sqlDataType = SqlDataType.FLOAT;
        } else

        if (field.getType().equals(double.class)) {
            sqlDataType = SqlDataType.DOUBLE;
        } else

        if (field.getType().equals(long.class)) {
            sqlDataType = SqlDataType.BIGINT;
        } else

        if (field.getType().equals(char.class)) {
            sqlDataType = SqlDataType.CHAR;
        } else

        if (field.getType().equals(String.class)) {
            sqlDataType = matchSqlTypeForStrField(field);
        } else

        if (field.getType().equals(java.util.Date.class)) {
            sqlDataType = SqlDataType.BIGINT;
        }

        else {
            throw new UnknownDataTypeException(
                    "Can't find appropriate sql type for a field " + field.getName() + " "
                  + "with type " + field.getType());
        }

        return sqlDataType;
    }

    private SqlDataType matchSqlTypeForStrField(
            final Field field) throws MarshalerException {

        SqlDataType sqlDataType = SqlDataType.VARCHAR;
        if (field.isAnnotationPresent(MaunParameters.class)) {
            final long maxSize = field.getAnnotation(MaunParameters.class).maxLength();

            if (maxSize < 0) {
                    new MarshalerException(
                        "String value length must be a positive number.");
            } else if ((maxSize == 0) || (maxSize > Character.MAX_VALUE)) {
                sqlDataType = SqlDataType.LONGTEXT;
            }
        }

        return sqlDataType;
    }
    
   
    private String defineMaxFieldSize(
            final Field field,
            final SqlDataType sqlDataType){

        String maxSizeToString = "";
        if (sqlDataType.equals(SqlDataType.VARCHAR)) {
            final long maxSize;
            if (field.isAnnotationPresent(MaunParameters.class)) {
                maxSize = field.getAnnotation(MaunParameters.class).maxLength();
            } else {
                maxSize = DEFAULT_VARCHAR_LENGTH;
            }

            maxSizeToString = "(" + String.valueOf(maxSize) + ")";
        } else if (sqlDataType.equals(SqlDataType.CHAR)) {
            maxSizeToString = "(1)";
        }

        return maxSizeToString;
    }

    private void setTableFieldValue(
            final int index,
            final Field field,
            final PreparedStatement preparedStatement,
            final Object object) throws MarshalerException {

        try {
            final Object fieldValue = getFieldValue(field, object);
            final SqlDataType fieldType = matchSqlType(field);

            switch (fieldType) {
                case SMALLINT:
                    preparedStatement.setShort(index, (Short) fieldValue);
                    break;

                case INT:
                    preparedStatement.setInt(index, (Integer) fieldValue);
                    break;

                case FLOAT:
                    preparedStatement.setFloat(index, (Float) fieldValue);
                    break;

                case DOUBLE:
                    preparedStatement.setDouble(index, (Double) fieldValue);
                    break;

                case BIGINT:
                    preparedStatement.setLong(index, (Long) fieldValue);
                    break;

                case CHAR:
                    preparedStatement.setString(index, (String) fieldValue);
                    break;

                case VARCHAR:
                    final String strValue = (String) fieldValue;
                    final long realLength = strValue.length();

                    long maxLength;
                    if (field.isAnnotationPresent(MaunParameters.class)) {
                        maxLength = field.getAnnotation(MaunParameters.class).maxLength();
                    } else {
                        maxLength = DEFAULT_VARCHAR_LENGTH;
                    }

                    if (realLength <= maxLength) {
                        preparedStatement.setString(index, strValue);
                        break;
                    } else {
                        throw new MarshalerException(
                                "Failed to save an entity of "
                              + "type '" + object.getClass().getSimpleName() + "'. "
                              + "The '" + field.getName() + "' field has an illegal value: "
                              + "maximum allowed length is " + maxLength + " characters, "
                              + "but the field currently is " + realLength + " characters long.");
                    }

                case LONGTEXT:
                    preparedStatement.setString(index, (String) fieldValue);
                    break;

                case TINYINT:
                    final Byte byteValue;
                    if (field.getType().equals(boolean.class)) {
                        final Boolean boolValue = (Boolean) fieldValue;
                        if (boolValue) {
                            byteValue = 1;
                        } else {
                            byteValue = 0;
                        }
                    } else {
                        byteValue = (Byte) fieldValue;
                    }

                    preparedStatement.setByte(index, byteValue);
                    break;

                default:
                    throw new UnknownDataTypeException(
                            "Can't find an appropriate SQL type for a "
                          + "field '" + field.getName() + "' with "
                          + "type '" + field.getType() + "'.");
            }
        } catch (SQLException e) {
            throw new MarshalerException(
                    "Failed to prepare a SQL statement for saving the entity. "
                  + "Failed to add the field '" + field.getName() + "' value to "
                  + "the statement.", e);
        }
    }

    private Object getFieldValue(
            final Field field,
            final Object object) throws MarshalerException {

        try {
            final Class clazz = Class.forName(object.getClass().getName());

            Method method;
            try {
                method = clazz.getMethod(defineGetMethodName(field));
            } catch (NoSuchMethodException e) {
                // Knowingly not logging the exception here. We do not care.

                try {
                    method = clazz.getMethod(defineIsMethodName(field));
                } catch (NoSuchMethodException ex) {
                    throw new MarshalerException(
                            "Failed to invoke the accessor method for the "
                          + "field '" + field.getName() + "' while marshaling an object of "
                          + "the class '" + object.getClass().getName() + "'.", ex);
                }
            }

            return method.invoke(object);
        } catch (ClassNotFoundException e) {
            throw new MarshalerException(
                    "Failed to invoke the accessor method for the "
                  + "field '" + field.getName() + "' while marshaling an object of "
                  + "the class '" + object.getClass().getName() + "'.", e);
        } catch (IllegalAccessException e) {
            throw new MarshalerException(
                    "Failed to obtain the value of the field '" + field.getName() + "'.", e);
        } catch (InvocationTargetException e) {
            throw new MarshalerException(
                    "Failed to invoke the accessor method for the "
                  + "field '" + field.getName() + "' while marshaling an object of "
                  + "the class '" + object.getClass().getName() + "'.", e);
        }
    }

    private void checkAndCloseStatement(
            final Statement statement) {

        if (statement != null) {
            try {
                statement.close();
            } catch (SQLException e) {
                LOGGER.log(Level.WARNING,
                        "Statement wasn't closed.", e);
            }
        }
    }

    ////////////////////////////////////////////////////////////////////////////////////////////////
    // Inner classes

    private enum SqlDataType {

        TINYINT, SMALLINT, INT, FLOAT, DOUBLE, BIGINT, CHAR, VARCHAR, LONGTEXT;
                       
    }
    
    ////////////////////////////////////////////////////////////////////////////////////////////////
    // Constants

    private static final long DEFAULT_VARCHAR_LENGTH = 4096;

}
